package main

import (
	"context"
	_ "embed"
	"errors"
	"fmt"
	"os"
	"os/signal"
	"path/filepath"
	"runtime"
	"strings"
	"sync/atomic"
	"syscall"
	"time"

	"fyne.io/systray"
	"github.com/fleetdm/fleet/v4/orbit/cmd/desktop/menu"
	"github.com/fleetdm/fleet/v4/orbit/pkg/constant"
	"github.com/fleetdm/fleet/v4/orbit/pkg/go-paniclog"
	"github.com/fleetdm/fleet/v4/orbit/pkg/migration"
	"github.com/fleetdm/fleet/v4/orbit/pkg/profiles"
	"github.com/fleetdm/fleet/v4/orbit/pkg/token"
	"github.com/fleetdm/fleet/v4/orbit/pkg/update"
	"github.com/fleetdm/fleet/v4/orbit/pkg/useraction"
	"github.com/fleetdm/fleet/v4/pkg/certificate"
	"github.com/fleetdm/fleet/v4/pkg/fleethttp"
	"github.com/fleetdm/fleet/v4/pkg/open"
	"github.com/fleetdm/fleet/v4/server/fleet"
	"github.com/fleetdm/fleet/v4/server/service"
	"github.com/gofrs/flock"
	"github.com/oklog/run"
	"github.com/rs/zerolog"
	"github.com/rs/zerolog/log"
	"gopkg.in/natefinch/lumberjack.v2"
)

// version is set at compile time via -ldflags
var version = "unknown"

func setupRunners() {
	var runnerGroup run.Group

	// Setting up a watcher for the communication channel
	if runtime.GOOS == "windows" {
		runnerGroup.Add(
			func() error {
				// block wait on the communication channel
				if err := blockWaitForStopEvent(constant.DesktopAppExecName); err != nil {
					log.Error().Err(err).Msg("There was an error on the desktop communication channel")
					return err
				}

				log.Info().Msg("Shutdown was requested!")
				return nil
			},
			func(err error) {
				systray.Quit()
			},
		)
	}

	if err := runnerGroup.Run(); err != nil {
		log.Error().Err(err).Msg("Fleet Desktop runners terminated")
		return
	}
}

func main() {
	// FIXME: we need to do a better job of graceful shutdown, releasing resources, stopping
	// tickers, etc. (https://github.com/fleetdm/fleet/issues/21256)
	// This context will be used as a general context to handle graceful shutdown in the future.
	offlineWatcherCtx, cancelOfflineWatcherCtx := context.WithCancel(context.Background())

	// Orbits uses --version to get the fleet-desktop version. Logs do not need to be set up when running this.
	if len(os.Args) > 1 && os.Args[1] == "--version" {
		// Must work with update.GetVersion
		fmt.Println("fleet-desktop", version)
		return
	}

	setupLogs()
	setupStderr()

	// Our TUF provided targets must support launching with "--help".
	if len(os.Args) > 1 && os.Args[1] == "--help" {
		fmt.Println("Fleet Desktop application executable")
		return
	}
	log.Info().Msgf("fleet-desktop version=%s", version)

	if permanentError := os.Getenv("FLEET_DESKTOP_PERMANENT_ERROR"); permanentError != "" {
		runWithPermanentError(permanentError)
	}

	identifierPath := os.Getenv("FLEET_DESKTOP_DEVICE_IDENTIFIER_PATH")
	if identifierPath == "" {
		log.Fatal().Msg("missing URL environment FLEET_DESKTOP_DEVICE_IDENTIFIER_PATH")
	}

	fleetURL := os.Getenv("FLEET_DESKTOP_FLEET_URL")
	if fleetURL == "" {
		log.Fatal().Msg("missing URL environment FLEET_DESKTOP_FLEET_URL")
	}

	fleetTLSClientCertificate := os.Getenv("FLEET_DESKTOP_FLEET_TLS_CLIENT_CERTIFICATE")
	fleetTLSClientKey := os.Getenv("FLEET_DESKTOP_FLEET_TLS_CLIENT_KEY")
	fleetClientCrt, err := certificate.LoadClientCertificate(fleetTLSClientCertificate, fleetTLSClientKey)
	if err != nil {
		log.Fatal().Err(err).Msg("load fleet tls client certificate")
	}
	fleetAlternativeBrowserHost := os.Getenv("FLEET_DESKTOP_ALTERNATIVE_BROWSER_HOST")
	if fleetClientCrt != nil {
		log.Info().Msg("Using TLS client certificate and key to authenticate to the server.")
	}
	tufUpdateRoot := os.Getenv("FLEET_DESKTOP_TUF_UPDATE_ROOT")
	if tufUpdateRoot != "" {
		log.Info().Msgf("got a TUF update root: %s", tufUpdateRoot)
	}

	// We've only seen this bug appear on Linux under certain very
	// specific conditions
	if runtime.GOOS == "linux" {
		// Ensure only one instance of Fleet Desktop is running at a time
		lockFile, err := getLockfile()
		if err != nil {
			log.Fatal().Err(err).Msg("could not secure lock file")
		}
		defer func() {
			if err := lockFile.Unlock(); err != nil {
				log.Error().Err(err).Msg("unlocking lockfile")
			}
		}()
	}

	// Setting up working runners such as signalHandler runner
	go setupRunners()

	var mdmMigrator useraction.MDMMigrator
	// swiftDialogCh is a channel shared by the migrator and the offline watcher to
	// coordinate the display of the dialog and ensure only one dialog is shown at a time.
	var swiftDialogCh chan struct{}
	var offlineWatcher useraction.MDMOfflineWatcher

	// We will execute the summary API every 5 minutes (to refresh policy state).
	const desktopSummaryInterval = 5 * time.Minute

	// This ticker is used for checking connectivity. It is initialized here because it is
	// stopped in `OnExit.`
	const pingInterval = 10 * time.Second // same value as default distributed/read
	pingTicker := time.NewTicker(pingInterval)

	// Used to trigger a policy check when clicking on "My device" or "About Fleet".
	var fleetDesktopCheckTrigger atomic.Bool

	// we have seen some cases where systray.Run() does not call onReady seemingly due to early
	// initialization states with the GUI such as Windows Autopilot first time setup. This ensures
	// we don't just hang forever waiting for the GUI to be ready.
	trayAppDisplayed := make(chan struct{})
	go func() {
		select {
		case <-trayAppDisplayed:
			// The tray app is ready and displayed so there is nothing to do
		case <-time.After(1 * time.Minute):
			log.Fatal().Msg("onReady was never called - the GUI may not yet be ready")
		}
	}()

	onReady := func() {
		close(trayAppDisplayed)
		log.Info().Msg("ready")

		systray.SetTooltip("Fleet Desktop")

		// Default to dark theme icon because this seems to be a better fit on Linux (Ubuntu at
		// least). On macOS this is used as a template icon anyway.
		systray.SetTemplateIcon(iconDark, iconDark)

		// Initialize menu manager with systray factory
		menuManager := menu.NewManager(version, menu.NewSystrayFactory())

		tokenReader := token.Reader{Path: identifierPath}
		if _, err := tokenReader.Read(); err != nil {
			log.Fatal().Err(err).Msg("error reading device token from file")
		}

		var insecureSkipVerify bool
		if os.Getenv("FLEET_DESKTOP_INSECURE") != "" {
			insecureSkipVerify = true
		}
		rootCA := os.Getenv("FLEET_DESKTOP_FLEET_ROOT_CA")

		client, err := service.NewDeviceClient(
			fleetURL,
			insecureSkipVerify,
			rootCA,
			fleetClientCrt,
			fleetAlternativeBrowserHost,
		)
		if err != nil {
			log.Fatal().Err(err).Msg("unable to initialize request client")
		}

		client.WithInvalidTokenRetry(func() string {
			log.Debug().Msg("refetching token from disk for API retry")
			newToken, err := tokenReader.Read()
			if err != nil {
				log.Error().Err(err).Msg("refetch token from disk for API retry")
				return ""
			}
			log.Debug().Msg("successfully refetched the token from disk for API retry")
			return newToken
		})

		reportError := func(err error, info map[string]any) {
			if !client.GetServerCapabilities().Has(fleet.CapabilityErrorReporting) {
				log.Info().Msg("skipped reporting error to the server as it doesn't have the capability enabled")
				return
			}

			fleetdErr := fleet.FleetdError{
				ErrorSource:         "fleet-desktop",
				ErrorSourceVersion:  version,
				ErrorTimestamp:      time.Now(),
				ErrorMessage:        err.Error(),
				ErrorAdditionalInfo: info,
			}

			if err := client.ReportError(tokenReader.GetCached(), fleetdErr); err != nil {
				log.Error().Err(err).EmbedObject(fleetdErr).Msg("reporting error to Fleet server")
			}
		}

		if runtime.GOOS == "darwin" {
			m, s, o, err := mdmMigrationSetup(offlineWatcherCtx, tufUpdateRoot, fleetURL, client, &tokenReader)
			if err != nil {
				go reportError(err, nil)
				log.Error().Err(err).Msg("setting up MDM migration resources")
			}

			mdmMigrator = m
			swiftDialogCh = s
			offlineWatcher = o
		}

		refetchToken := func() {
			if _, err := tokenReader.Read(); err != nil {
				log.Error().Err(err).Msg("refetch token")
			}
			log.Debug().Msg("successfully refetched the token from disk")
		}

		// checkToken performs API test calls to enable the "My device" item as
		// soon as the device auth token is registered by Fleet.
		checkToken := func() <-chan interface{} {
			menuManager.SetConnecting()
			done := make(chan interface{})

			go func() {
				ticker := time.NewTicker(5 * time.Second)
				defer ticker.Stop()
				defer close(done)

				for {
					refetchToken()
					summary, err := client.DesktopSummary(tokenReader.GetCached())

					if err == nil || errors.Is(err, service.ErrMissingLicense) {
						log.Debug().Msg("enabling tray items")
						isFreeTier := errors.Is(err, service.ErrMissingLicense)
						var desktopSummary *fleet.DesktopSummary
						if summary != nil {
							desktopSummary = &summary.DesktopSummary
						}
						menuManager.SetConnected(desktopSummary, isFreeTier)

						return
					}

					log.Error().Err(err).Msg("get device URL")

					<-ticker.C
				}
			}()

			return done
		}

		// start a check as soon as the app starts
		deviceEnabledChan := checkToken()

		// this loop checks the `mtime` value of the token file and:
		// 1. if the token file was modified, it disables the tray items until we
		// verify the token is valid
		// 2. calls (blocking) `checkToken` to verify the token is valid
		go func() {
			<-deviceEnabledChan
			tic := time.NewTicker(1 * time.Second)
			defer tic.Stop()

			for {
				<-tic.C
				expired, err := tokenReader.HasChanged()
				switch {
				case err != nil:
					log.Error().Err(err).Msg("check token file")
				case expired:
					log.Info().Msg("token file expired or invalid, rechecking")
					<-checkToken()
				}
			}
		}()

		// poll the server to check the policy status of the host and update the
		// tray icon accordingly.
		// We first ping the server to check for connectivity, then get the policy status (every 5 minutes to
		// not cause performance issues on the server).
		go func() {
			<-deviceEnabledChan

			var (
				pingErrCount            = 0
				lastDesktopSummaryCheck time.Time
			)

			for {
				<-pingTicker.C

				// Reset the ticker to the intended interval,
				// in case we reset it to 1ms (when clicking on "My device").
				pingTicker.Reset(pingInterval)

				if err := client.Ping(); err != nil {
					log.Error().Err(err).Int("count", pingErrCount).Msg("ping failed")
					pingErrCount++
					// We try 5 more times to make sure one bad request doesn't trigger the offline indicator.
					// So it might take up to ~1m (6 * 10s) for Fleet Desktop to show the offline indicator.
					if pingErrCount >= 6 {
						menuManager.SetOffline()
					}
					continue
				}

				// Successfully connected to Fleet.
				pingErrCount = 0

				// Check if we need to fetch the "Fleet desktop" summary from Fleet.
				if !menuManager.IsOfflineIndicatorDisplayed() &&
					!fleetDesktopCheckTrigger.Load() &&
					(!lastDesktopSummaryCheck.IsZero() && time.Since(lastDesktopSummaryCheck) < desktopSummaryInterval) {
					continue
				}

				lastDesktopSummaryCheck = time.Now()
				fleetDesktopCheckTrigger.Store(false)
				// We set offlineIndicatorDisplayed to false because we do not want to retry the
				// Fleet Desktop summary every 10s if Ping works but DesktopSummary doesn't
				// (to avoid server load issues).
				menuManager.SetOfflineIndicatorDisplayed(false)

				sum, err := client.DesktopSummary(tokenReader.GetCached())
				if err != nil {
					switch {
					case errors.Is(err, service.ErrMissingLicense):
						// Policy reporting in Fleet Desktop requires a license,
						// so we just show the "My device" item as usual.
						menuManager.SetConnected(&fleet.DesktopSummary{}, true)
					case errors.Is(err, service.ErrUnauthenticated):
						log.Debug().Err(err).Msg("get desktop summary auth failure")
						// This usually happens every ~1 hour when the token expires.
						<-checkToken()
					default:
						log.Error().Err(err).Msg("get desktop summary failed")
					}
					continue
				}

				menuManager.SetConnected(&sum.DesktopSummary, false)
				menuManager.UpdateFailingPolicies(sum.DesktopSummary.FailingPolicies)

				// Check our file to see if we should migrate
				var migrationType string
				if runtime.GOOS == "darwin" {
					migrationType, err = mdmMigrator.MigrationInProgress()
					if err != nil {
						go reportError(err, nil)
						log.Error().Err(err).Msg("checking if MDM migration is in progress")
					}
				}

				migrationInProgress := migrationType != ""

				shouldRunMigrator := sum.Notifications.NeedsMDMMigration || sum.Notifications.RenewEnrollmentProfile || migrationInProgress

				if runtime.GOOS == "darwin" && shouldRunMigrator && mdmMigrator.CanRun() {
					enrolled, enrollURL, err := profiles.IsEnrolledInMDM()
					if err != nil {
						log.Error().Err(err).Msg("fetching enrollment status to show mdm migrator")
						continue
					}

					// we perform this check locally on the client too to avoid showing the
					// dialog if the client has already migrated but the Fleet server
					// doesn't know about this state yet.
					enrolledIntoFleet, err := fleethttp.HostnamesMatch(enrollURL, fleetURL)
					if err != nil {
						log.Error().Err(err).Msg("comparing MDM server URLs")
						continue
					}
					if !enrolledIntoFleet {
						// isUnmanaged captures two important bits of information:
						//
						// - The notification coming from the server, which is based on information that's
						//   not available in the client (eg: is MDM configured? are migrations enabled?
						//   is this device elegible for migration?)
						// - The current enrollment status of the device.
						isUnmanaged := sum.Notifications.RenewEnrollmentProfile && !enrolled
						forceModeEnabled := sum.Notifications.NeedsMDMMigration &&
							sum.Config.MDM.MacOSMigration.Mode == fleet.MacOSMigrationModeForced

						// update org info in case it changed
						mdmMigrator.SetProps(useraction.MDMMigratorProps{
							OrgInfo:     sum.Config.OrgInfo,
							IsUnmanaged: isUnmanaged,
						})

						// enable tray items
						if migrationType != constant.MDMMigrationTypeADE {
							menuManager.SetMDMMigratorVisibility(true)
						} else {
							menuManager.SetMDMMigratorVisibility(false)
						}

						// if the device is unmanaged or we're in force mode and the device needs
						// migration, enable aggressive mode.
						if isUnmanaged || forceModeEnabled || migrationInProgress {
							log.Info().Msg("MDM device is unmanaged or force mode enabled, automatically showing dialog")
							if err := mdmMigrator.ShowInterval(); err != nil {
								go reportError(err, nil)
								log.Error().Err(err).Msg("showing MDM migration dialog at interval")
							}
						}
					} else {
						// we're done with the migration, so mark it as complete.
						if err := mdmMigrator.MarkMigrationCompleted(); err != nil {
							go reportError(err, nil)
							log.Error().Err(err).Msg("failed to mark MDM migration as completed")
						}
						menuManager.SetMDMMigratorVisibility(false)
					}
				} else {
					menuManager.SetMDMMigratorVisibility(false)
				}
			}
		}()

		go func() {
			for {
				select {
				case <-menuManager.Items.MyDevice.ClickedCh():
					openURL := client.BrowserPoliciesURL(tokenReader.GetCached())
					if err := open.Browser(openURL); err != nil {
						log.Error().Err(err).Str("url", openURL).Msg("open browser policies")
					}
					// Also refresh the device status by forcing the polling ticker to fire
					fleetDesktopCheckTrigger.Store(true)
					pingTicker.Reset(1 * time.Millisecond)
				case <-menuManager.Items.Transparency.ClickedCh():
					openURL := client.BrowserTransparencyURL(tokenReader.GetCached())
					if err := open.Browser(openURL); err != nil {
						log.Error().Err(err).Str("url", openURL).Msg("open browser transparency")
					}
				case <-menuManager.Items.SelfService.ClickedCh():
					openURL := client.BrowserSelfServiceURL(tokenReader.GetCached())
					if err := open.Browser(openURL); err != nil {
						log.Error().Err(err).Str("url", openURL).Msg("open browser self-service")
					}
					// Also refresh the device status by forcing the polling ticker to fire
					fleetDesktopCheckTrigger.Store(true)
					pingTicker.Reset(1 * time.Millisecond)
				case <-menuManager.Items.MigrateMDM.ClickedCh():
					if offline := offlineWatcher.ShowIfOffline(offlineWatcherCtx); offline {
						continue
					}

					if err := mdmMigrator.Show(); err != nil {
						go reportError(err, nil)
						log.Error().Err(err).Msg("showing MDM migration dialog on user action")
					}
				}
			}
		}()
	}

	// FIXME: it doesn't look like this is actually triggering, at least when desktop gets
	// killed (https://github.com/fleetdm/fleet/issues/21256)
	onExit := func() {
		log.Info().Msg("exiting")
		if mdmMigrator != nil {
			log.Debug().Err(err).Msg("exiting mdmMigrator")
			mdmMigrator.Exit()
		}
		if swiftDialogCh != nil {
			log.Debug().Err(err).Msg("exiting swiftDialogCh")
			close(swiftDialogCh)
		}
		log.Debug().Msg("stopping ping ticker")
		pingTicker.Stop()
		log.Debug().Msg("canceling offline watcher ctx")
		cancelOfflineWatcherCtx()
	}

	sigChan := make(chan os.Signal, 1)
	signal.Notify(
		sigChan,
		syscall.SIGINT,
		syscall.SIGTERM,
		syscall.SIGQUIT,
	)

	// Catch signals and exit gracefully
	go func() {
		s := <-sigChan
		log.Info().Stringer("signal", s).Msg("Caught signal, exiting")
		systray.Quit()
	}()

	// Check for the system tray icon periodically and kill the process if it's missing,
	// forcing the parent to restart it. This may happen if a Linux display manager
	// is restarted.
	if runtime.GOOS == "linux" {
		log.Debug().Msg("starting tray icon checker")
		go func() {
			checkTrayIconTicker := time.NewTicker(5 * time.Second)

			for {
				<-checkTrayIconTicker.C
				if !trayIconExists() {
					log.Warn().Msg("system tray icon missing, exiting")
					// Cleanly stop systray.
					systray.Quit()
					// Exit to trigger restart.
					os.Exit(75)
				}
			}
		}()
	}

	systray.Run(onReady, onExit)
}

type mdmMigrationHandler struct {
	client      *service.DeviceClient
	tokenReader *token.Reader
}

func (m *mdmMigrationHandler) NotifyRemote() error {
	log.Info().Msg("sending request to trigger mdm migration webhook")

	// TODO: Revisit if/when we should hide the migration menu item depending on the
	// result of the client request.
	if err := m.client.MigrateMDM(m.tokenReader.GetCached()); err != nil {
		log.Error().Err(err).Msg("triggering migration webhook")
		return fmt.Errorf("on migration start: %w", err)
	}
	log.Info().Msg("successfully sent request to trigger mdm migration webhook")
	return nil
}

func (m *mdmMigrationHandler) ShowInstructions() error {
	openURL := m.client.BrowserDeviceURL(m.tokenReader.GetCached())
	if err := open.Browser(openURL); err != nil {
		log.Error().Err(err).Str("url", openURL).Msg("open browser my device (mdm migration handler)")
		return err
	}
	return nil
}

// getLockfile checks for the fleet desktop lock file, and returns an error if it can't secure it.
func getLockfile() (*flock.Flock, error) {
	dir, err := logDir()
	if err != nil {
		return nil, fmt.Errorf("unable to get logdir for lock: %w", err)
	}
	// Same as the log dir in setupLogs()
	dir = filepath.Join(dir, "Fleet")

	lockFilePath := filepath.Join(dir, "fleet-desktop.lock")
	log.Debug().Msgf("acquiring fleet desktop lockfile: %s", lockFilePath)

	lock := flock.New(lockFilePath)
	locked, err := lock.TryLock()
	if err != nil {
		return nil, fmt.Errorf("error getting lock on %s: %w", lockFilePath, err)
	}
	if !locked {
		return nil, errors.New("another instance of fleet desktop has the lock")
	}

	log.Debug().Msgf("lock acquired on %s", lockFilePath)

	return lock, nil
}

// setupLogs configures our logging system to write logs to rolling files, if for some
// reason we can't write a log file the logs are still printed to stderr.
func setupLogs() {
	dir, err := logDir()
	if err != nil {
		stderrOut := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano, NoColor: true}
		log.Logger = log.Output(stderrOut)
		log.Error().Err(err).Msg("find directory for logs")
		return
	}

	dir = filepath.Join(dir, "Fleet")

	if err := os.MkdirAll(dir, 0o755); err != nil {
		stderrOut := zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339Nano, NoColor: true}
		log.Logger = log.Output(stderrOut)
		log.Error().Err(err).Msg("make directories for log files")
		return
	}

	logFile := &lumberjack.Logger{
		Filename:   filepath.Join(dir, "fleet-desktop.log"),
		MaxSize:    25, // megabytes
		MaxBackups: 3,
		MaxAge:     28, // days
	}

	consoleWriter := zerolog.ConsoleWriter{Out: logFile, TimeFormat: time.RFC3339Nano, NoColor: true}
	log.Logger = log.Output(consoleWriter)
}

// setupStderr redirects stderr output to a file.
func setupStderr() {
	dir, err := logDir()
	if err != nil {
		log.Error().Err(err).Msg("find directory for stderr")
		return
	}

	if err := os.MkdirAll(dir, 0o755); err != nil {
		log.Error().Err(err).Msg("make directories for stderr")
		return
	}

	stderrFile, err := os.OpenFile(filepath.Join(dir, "Fleet", "fleet-desktop.err"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o666) // nolint:gosec // G302
	if err != nil {
		log.Error().Err(err).Msg("create file to redirect stderr")
		return
	}
	defer stderrFile.Close()

	if _, err := stderrFile.Write([]byte(time.Now().UTC().Format("2006-01-02T15-04-05") + "\n")); err != nil {
		log.Error().Err(err).Msg("write to stderr file")
	}

	// We need to use this method to properly capture golang's panic stderr output.
	// Just setting os.Stderr to a file doesn't work (Go's runtime is probably using os.Stderr
	// very early).
	if _, err := paniclog.RedirectStderr(stderrFile); err != nil {
		log.Error().Err(err).Msg("redirect stderr to file")
	}
}

// logDir returns the default root directory to use for application-level logs.
//
// On Unix systems, it returns $XDG_STATE_HOME as specified by
// https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html if
// non-empty, else $HOME/.local/state.
// On Darwin, it returns $HOME/Library/Logs.
// On Windows, it returns %LocalAppData%
//
// If the location cannot be determined (for example, $HOME is not defined),
// then it will return an error.
func logDir() (string, error) {
	var dir string

	switch runtime.GOOS {
	case "windows":
		dir = os.Getenv("LocalAppData")
		if dir == "" {
			return "", errors.New("%LocalAppData% is not defined")
		}

	case "darwin":
		dir = os.Getenv("HOME")
		if dir == "" {
			return "", errors.New("$HOME is not defined")
		}
		dir += "/Library/Logs"

	default: // Unix
		dir = os.Getenv("XDG_STATE_HOME")
		if dir == "" {
			dir = os.Getenv("HOME")
			if dir == "" {
				return "", errors.New("neither $XDG_STATE_HOME nor $HOME are defined")
			}
			dir += "/.local/state"
		}
	}

	return dir, nil
}

func mdmMigrationSetup(ctx context.Context, tufUpdateRoot, fleetURL string, client *service.DeviceClient, tokenReader *token.Reader) (useraction.MDMMigrator, chan struct{}, useraction.MDMOfflineWatcher, error) {
	dir, err := migration.Dir()
	if err != nil {
		return nil, nil, nil, err
	}

	mrw := migration.NewReadWriter(dir, constant.MigrationFileName)

	// we use channel buffer size of 1 to allow one dialog at a time with non-blocking sends.
	swiftDialogCh := make(chan struct{}, 1)

	_, swiftDialogPath, _ := update.LocalTargetPaths(
		tufUpdateRoot,
		"swiftDialog",
		update.SwiftDialogMacOSTarget,
	)
	mdmMigrator := useraction.NewMDMMigrator(
		swiftDialogPath,
		15*time.Minute,
		&mdmMigrationHandler{
			client:      client,
			tokenReader: tokenReader,
		},
		mrw,
		fleetURL,
		swiftDialogCh,
	)

	offlineWatcher := useraction.StartMDMMigrationOfflineWatcher(ctx, client, swiftDialogPath, swiftDialogCh, migration.FileWatcher(mrw))

	return mdmMigrator, swiftDialogCh, offlineWatcher, nil
}

func runWithPermanentError(errorMessage string) {
	onReady := func() {
		log.Info().Msg("ready")

		systray.SetTooltip("Fleet Desktop")

		// Default to dark theme icon because this seems to be a better fit on Linux (Ubuntu at
		// least). On macOS this is used as a template icon anyway.
		systray.SetTemplateIcon(iconDark, iconDark)

		// Add a disabled menu item with the current version
		versionItem := systray.AddMenuItem(fmt.Sprintf("Fleet Desktop v%s", version), "")
		versionItem.Disable()
		systray.AddSeparator()

		// We are doing this using two menu items because line breaks
		// are not rendered correctly on Windows and MacOS.
		for errorMessageLine := range strings.SplitSeq(errorMessage, "\n") {
			item := systray.AddMenuItem(strings.TrimSpace(errorMessageLine), "")
			item.Disable()
		}
	}

	systray.Run(onReady, func() {
		log.Info().Msg("exit")
	})
}
